Skip to content

fix(Worklets): RemoteFunction memory cycle#9673

Merged
tjzel merged 2 commits into
mainfrom
@tjzel/worklets/remote-leak
Jun 16, 2026
Merged

fix(Worklets): RemoteFunction memory cycle#9673
tjzel merged 2 commits into
mainfrom
@tjzel/worklets/remote-leak

Conversation

@tjzel

@tjzel tjzel commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #9661

In #9272 which was supposed to fix memory issues with Remote Function, I accidentally introduced a memory-leak due to reference retain cycle.

Before

Basically, RemoteFunctionRegistry would hold a strong reference to the (remote) function, which in turn would hold a strong reference to the serialized remote function, which would hold the C++ value.

This C++ value would erase the original function from the registry, but only in its destructor - which would never happen, because the registry itself would keep it alive forever.

flowchart TB
  subgraph RN[RN Runtime]
    R[RemoteFunctionRegistry]
    F[remote function]
    C[serialized remote function]
  end
  subgraph CPP[C++ heap]
    S[C++ value]
  end
  subgraph WK[Worker Runtime]
    H[holderFunction]
  end

  R --> F
  F -.-> C
  C --> S
  H --> S
  S ==>|"registry.delete(id) — never invoked"| R

  linkStyle 0,2,3 stroke:#2f9e44,stroke-width:2px
  linkStyle 1 stroke:#868e96,stroke-width:2px
  linkStyle 4 stroke:#e03131,stroke-width:3px
Loading

After

To fix this issue, I split the C++ Remote Function for RN callbacks into two subclasses - one would only be owned by the RN Runtime, and the other one from Worklet Runtimes.

If all Worklet Runtime references would get cleaned up, then they would schedule cleanup on the RN Runtime C++ side, to erase the original function from the registry. Then, this would in turn release the cache value and the remaining C++ value.

flowchart TB
  subgraph RN[RN Runtime]
    R[RemoteFunctionRegistry]
    F[remote function]
    C[serialized remote function]
  end
  subgraph CPP[C++ heap]
    O[RN-owned C++ value]
    P[Worklet-owned C++ value]
  end
  subgraph WK[Worker Runtime]
    H[holderFunction]
  end

  R --> F
  F -.-> C
  C --> O
  H --> P
  P --> O
  O -.-> P
  P ==>|"registry.delete(id)"| R

  linkStyle 0,2,3,4 stroke:#2f9e44,stroke-width:2px
  linkStyle 1,5 stroke:#868e96,stroke-width:2px
  linkStyle 6 stroke:#2f9e44,stroke-width:3px
Loading

Test Plan

I used the following code and was tracking constructor and destructor invocations of SerializableRemoteFunction in debugger.

Code

import { useEffect } from 'react';
import { Button, StyleSheet, View } from 'react-native';
import {
  scheduleOnRN,
  scheduleOnUI,
  createWorkletRuntime,
  scheduleOnRuntime,
  createSerializable,
} from 'react-native-worklets';

const workerRuntime = createWorkletRuntime();

export default function App() {
  useEffect(() => {
    scheduleOnUI(() => {
      function foo() {
        const _ = new Array(10000).fill(0);
        requestAnimationFrame(foo);
      }
      foo();
    });
  });

  return (
    <View style={styles.container}>
      <Button
        title="Init runtime"
        onPress={() => {
          scheduleOnUI(() => {
            // scheduleOnRuntime(workerRuntime, () => {
            'worklet';
            console.log('init');
          });
        }}
      />
      <Button
        title="Press me first"
        onPress={() => {
          function logTest() {
            console.log('Button pressed!');
          }

          // scheduleOnRuntime(workerRuntime, () => {
          scheduleOnUI(() => {
            'worklet';
            scheduleOnRN(logTest);
          });
        }}
      />
      <Button
        title="Trigger GC on RN"
        onPress={() => {
          console.log('Triggering GC on RN');
          gc();
        }}
      />
      <Button
        title="Trigger GC on Worker"
        onPress={() => {
          // scheduleOnRuntime(workerRuntime, () => {
          scheduleOnUI(() => {
            'worklet';
            console.log('Triggering GC on Worker');
            gc();
          });
        }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

@tjzel
tjzel marked this pull request as draft June 16, 2026 12:59
@tjzel
tjzel marked this pull request as ready for review June 16, 2026 14:04
@tjzel
tjzel requested a review from bartlomiejbloniarz June 16, 2026 14:05
@tjzel
tjzel added this pull request to the merge queue Jun 16, 2026
Merged via the queue into main with commit 7c86624 Jun 16, 2026
13 checks passed
@tjzel
tjzel deleted the @tjzel/worklets/remote-leak branch June 16, 2026 14:46
pull Bot pushed a commit to BasixKOR/react-native-reanimated that referenced this pull request Jul 1, 2026
…date status (software-mansion#9789)

## Summary

Fixes software-mansion#9751 

Changing (again) how RN Runtime remote functions (RFs) are managed,
since changes from software-mansion#9673 and software-mansion#9272 were actually insufficient and the
map approach won't work.

It's impossible to keep both the actual callback and cached serialized
callback value alive without a memory cycle, so I'm dropping cache
consistency. This might result in multiple serializations of a given RF
the GC triggered and the serialized value wasn't saved, but the
serialization of a RF is actually cheap.

I'm dropping the map approach entirely in favor of the behavior where
destructor invocation is based upon the information if the runtime is
still alive. We can't directly check it, so I'm depending on the
`invalidate()` method of Turbo Modules - which is supposed to be called
when the runtime is still alive.

## Test plan

You can test it on
[df3e344](software-mansion@df3e344)
commit - there's a special registry there that artificially extends the
lifetime of Remote Functions - click some buttons on the provided
example, then reload the app with R button. The callbacks with persist
and will crash on destructor invocation without the check for `isDead`.
tjzel added a commit that referenced this pull request Jul 1, 2026
…date status (#9789)

## Summary

Fixes #9751

Changing (again) how RN Runtime remote functions (RFs) are managed,
since changes from #9673 and #9272 were actually insufficient and the
map approach won't work.

It's impossible to keep both the actual callback and cached serialized
callback value alive without a memory cycle, so I'm dropping cache
consistency. This might result in multiple serializations of a given RF
the GC triggered and the serialized value wasn't saved, but the
serialization of a RF is actually cheap.

I'm dropping the map approach entirely in favor of the behavior where
destructor invocation is based upon the information if the runtime is
still alive. We can't directly check it, so I'm depending on the
`invalidate()` method of Turbo Modules - which is supposed to be called
when the runtime is still alive.

## Test plan

You can test it on
[df3e344](df3e344)
commit - there's a special registry there that artificially extends the
lifetime of Remote Functions - click some buttons on the provided
example, then reload the app with R button. The callbacks with persist
and will crash on destructor invocation without the check for `isDead`.

(cherry picked from commit 325a644)
tjzel added a commit that referenced this pull request Jul 14, 2026
…date status (#9789)

## Summary

Fixes #9751

Changing (again) how RN Runtime remote functions (RFs) are managed,
since changes from #9673 and #9272 were actually insufficient and the
map approach won't work.

It's impossible to keep both the actual callback and cached serialized
callback value alive without a memory cycle, so I'm dropping cache
consistency. This might result in multiple serializations of a given RF
the GC triggered and the serialized value wasn't saved, but the
serialization of a RF is actually cheap.

I'm dropping the map approach entirely in favor of the behavior where
destructor invocation is based upon the information if the runtime is
still alive. We can't directly check it, so I'm depending on the
`invalidate()` method of Turbo Modules - which is supposed to be called
when the runtime is still alive.

## Test plan

You can test it on
[df3e344](df3e344)
commit - there's a special registry there that artificially extends the
lifetime of Remote Functions - click some buttons on the provided
example, then reload the app with R button. The callbacks with persist
and will crash on destructor invocation without the check for `isDead`.

(cherry picked from commit 325a644)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

react-native-worklets memory leak: worklet-captured JS callbacks are never released

2 participants